<template>
  <div
      :class="classes"
      v-click-outside="onClickOutside"
  >
    <div
        ref="reference"
        :class="selectionCls"
        :tabindex="selectTabindex"
        @blur="toggleHeaderFocus"
        @focus="toggleHeaderFocus"
        @click="toggleMenu"
        @mouseenter="hasMouseHoverHead = true"
        @mouseleave="hasMouseHoverHead = false"
    >
      <slot name="input">
        <input type="hidden" :name="name" :value="publicValue">
        <select-head
            :filterable="filterable"
            :multiple="multiple"
            :values="values"
            :clearable="canBeCleared"
            :prefix="prefix"
            :disabled="disabled"
            :remote="remote"
            :input-element-id="elementId"
            :initial-label="initialLabel"
            :placeholder="placeholder"
            :query-prop="query"
            :max-tag-count="maxTagCount"
            :max-tag-placeholder="maxTagPlaceholder"
            @on-query-change="onQueryChange"
            @on-input-focus="isFocused = true"
            @on-input-blur="isFocused = false"
            @on-clear="clearSingleSelect"
        >
          <slot name="prefix" slot="prefix"></slot>
        </select-head>
      </slot>
    </div>
    <transition name="zoom-in-top">
      <drop
          :class="dropdownCls"
          v-show="dropVisible"
          :placement="placement"
          ref="dropdown"
          :data-transfer="appendToBody"
          :transfer="appendToBody"
          v-transfer-dom
      >
        <ul v-show="showNotFoundLabel" :class="[prefixCls + '-not-found']">
          <li>{{ notFoundText }}</li>
        </ul>
        <ul :class="prefixCls + '-dropdown-list'">
          <functional-options
              v-if="(!remote) || (remote && !loading)"
              :options="selectOptions"
              :slot-update-hook="updateSlotOptions"
              :slot-options="slotOptions"
          ></functional-options>
        </ul>
        <ul v-show="loading" :class="[prefixCls + '-loading']">loading...</ul>
      </drop>
    </transition>
  </div>
</template>

<script>
  import Drop from './drop.vue'
  import ClickOutside from '../../directive/clickoutside'
  import TransferDom from '../../directive/transfer-dom'
  import { oneOf } from '../../utils/util'
  import Emitter from '../../mixins/emitter'
  import SelectHead from './select-head.vue'
  import FunctionalOptions from './functional-options.vue'

  const prefixCls = 'bin-select'
  const optionRegexp = /^b-option$/i
  const optionGroupRegexp = /b-option-?group/i

  const findChild = (instance, checkFn) => {
    let match = checkFn(instance)
    if (match) return instance
    for (let i = 0, l = instance.$children.length; i < l; i++) {
      const child = instance.$children[i]
      match = findChild(child, checkFn)
      if (match) return match
    }
  }

  const findOptionsInVNode = (node) => {
    const opts = node.componentOptions
    if (opts && opts.tag.match(optionRegexp)) return [node]
    if (!node.children && (!opts || !opts.children)) return []
    const children = [...(node.children || []), ...((opts && opts.children) || [])]
    const options = children.reduce(
      (arr, el) => [...arr, ...findOptionsInVNode(el)], []
    ).filter(Boolean)
    return options.length > 0 ? options : []
  }

  const extractOptions = (options) => options.reduce((options, slotEntry) => {
    return options.concat(findOptionsInVNode(slotEntry))
  }, [])

  const applyProp = (node, propName, value) => {
    return {
      ...node,
      componentOptions: {
        ...node.componentOptions,
        propsData: {
          ...node.componentOptions.propsData,
          [propName]: value
        }
      }
    }
  }

  const getNestedProperty = (obj, path) => {
    const keys = path.split('.')
    return keys.reduce((o, key) => (o && o[key]) || null, obj)
  }

  const getOptionLabel = option => {
    if (option.componentOptions.propsData.label) return option.componentOptions.propsData.label
    const textContent = (option.componentOptions.children || []).reduce((str, child) => str + (child.text || ''), '')
    const innerHTML = getNestedProperty(option, 'data.domProps.innerHTML')
    return textContent || (typeof innerHTML === 'string' ? innerHTML : '')
  }

  const checkValuesNotEqual = (value, publicValue, values) => {
    const strValue = JSON.stringify(value)
    const strPublic = JSON.stringify(publicValue)
    const strValues = JSON.stringify(values.map(item => {
      return item.value
    }))
    return strValue !== strPublic || strValue !== strValues || strValues !== strPublic
  }

  const ANIMATION_TIMEOUT = 300

  export default {
    name: 'BSelect',
    mixins: [Emitter],
    components: { FunctionalOptions, Drop, SelectHead },
    directives: { ClickOutside, TransferDom },
    props: {
      value: {
        type: [String, Number, Array],
        default: ''
      },
      // 使用时，也得设置 value 才行
      label: {
        type: [String, Number, Array],
        default: ''
      },
      multiple: {
        type: Boolean,
        default: false
      },
      disabled: {
        type: Boolean,
        default: false
      },
      clearable: {
        type: Boolean,
        default: false
      },
      placeholder: {
        type: String,
        default: '请选择'
      },
      filterable: {
        type: Boolean,
        default: false
      },
      filterMethod: {
        type: Function
      },
      remoteMethod: {
        type: Function
      },
      loading: {
        type: Boolean,
        default: false
      },
      loadingText: {
        type: String
      },
      size: {
        validator(value) {
          return oneOf(value, ['small', 'large', 'default', 'mini'])
        },
        default: 'default'
      },
      labelInValue: {
        type: Boolean,
        default: false
      },
      notFoundText: {
        type: String,
        default: '没有数据'
      },
      placement: {
        validator(value) {
          return oneOf(value, ['top', 'bottom', 'top-start', 'bottom-start', 'top-end', 'bottom-end'])
        },
        default: 'bottom-start'
      },
      appendToBody: Boolean,
      // Use for AutoComplete
      autoComplete: {
        type: Boolean,
        default: false
      },
      name: {
        type: String
      },
      elementId: {
        type: String
      },
      transferClassName: {
        type: String
      },
      prefix: {
        type: String
      },
      maxTagCount: {
        type: Number
      },
      maxTagPlaceholder: {
        type: Function
      }
    },
    mounted() {
      this.$on('on-select-selected', this.onOptionClick)

      // set the initial values if there are any
      if (!this.remote && this.selectOptions.length > 0) {
        this.values = this.getInitialValue().map(value => {
          if (typeof value !== 'number' && !value) return null
          return this.getOptionData(value)
        }).filter(Boolean)
      }

      this.checkUpdateStatus()
    },
    data() {
      return {
        prefixCls: prefixCls,
        values: [],
        dropDownWidth: 0,
        visible: false,
        focusIndex: -1,
        isFocused: false,
        query: '',
        initialLabel: this.label,
        hasMouseHoverHead: false,
        slotOptions: this.$slots.default,
        caretPosition: -1,
        lastRemoteQuery: '',
        unchangedQuery: true,
        hasExpectedValue: false,
        preventRemoteCall: false,
        filterQueryChange: false
      }
    },
    computed: {
      classes() {
        return [
          `${prefixCls}`,
          {
            [`${prefixCls}-visible`]: this.visible,
            [`${prefixCls}-disabled`]: this.disabled,
            [`${prefixCls}-multiple`]: this.multiple,
            [`${prefixCls}-single`]: !this.multiple,
            [`${prefixCls}-show-clear`]: this.showCloseIcon,
            [`${prefixCls}-${this.size}`]: !!this.size
          }
        ]
      },
      dropdownCls() {
        return {
          [prefixCls + '-dropdown-transfer']: this.appendToBody,
          [prefixCls + '-multiple']: this.multiple && this.transfer,
          'bin-auto-complete': this.autoComplete,
          [this.transferClassName]: this.transferClassName
        }
      },
      selectionCls() {
        return {
          [`${prefixCls}-selection`]: !this.autoComplete,
          [`${prefixCls}-selection-focused`]: this.isFocused
        }
      },
      transitionName() {
        return this.placement === 'bottom' ? 'slide-up' : 'slide-down'
      },
      dropVisible() {
        let status = true
        const noOptions = !this.selectOptions || this.selectOptions.length === 0
        if (!this.loading && this.remote && this.query === '' && noOptions) status = false

        if (this.autoComplete && noOptions) status = false

        return this.visible && status
      },
      showNotFoundLabel() {
        const { loading, remote, selectOptions } = this
        return selectOptions && selectOptions.length === 0 && (!remote || (remote && !loading))
      },
      publicValue() {
        if (this.labelInValue) {
          return this.multiple ? this.values : this.values[0]
        } else {
          return this.multiple ? this.values.map(option => option.value) : (this.values[0] || {}).value
        }
      },
      canBeCleared() {
        const uiStateMatch = this.hasMouseHoverHead || this.active
        const qualifiesForClear = !this.multiple && !this.disabled && this.clearable
        return uiStateMatch && qualifiesForClear && this.reset // we return a function
      },
      selectOptions() {
        const selectOptions = []
        const slotOptions = (this.slotOptions || [])
        let optionCounter = -1
        const currentIndex = this.focusIndex
        const selectedValues = this.values.filter(Boolean).map(({ value }) => value)
        if (this.autoComplete) {
          const copyChildren = (node, fn) => {
            return {
              ...node,
              children: (node.children || []).map(fn).map(child => copyChildren(child, fn))
            }
          }
          const autoCompleteOptions = extractOptions(slotOptions)
          const selectedSlotOption = autoCompleteOptions[currentIndex]

          return slotOptions.map(node => {
            if (node === selectedSlotOption || getNestedProperty(node, 'componentOptions.propsData.value') === this.value) return applyProp(node, 'isFocused', true)
            return copyChildren(node, (child) => {
              if (child !== selectedSlotOption) return child
              return applyProp(child, 'isFocused', true)
            })
          })
        }
        for (let option of slotOptions) {
          const cOptions = option.componentOptions
          if (!cOptions) continue
          if (cOptions.tag.match(optionGroupRegexp)) {
            let children = cOptions.children
            if (this.filterable) {
              children = children.filter(
                ({ componentOptions }) => this.validateOption(componentOptions)
              )
            }
            children = children.map(opt => {
              optionCounter = optionCounter + 1
              return this.processOption(opt, selectedValues, optionCounter === currentIndex)
            })
            // keep the group if it still has children
            if (children.length > 0) {
              selectOptions.push({
                ...option,
                componentOptions: { ...cOptions, children: children }
              })
            }
          } else {
            if (this.filterQueryChange) {
              const optionPassesFilter = this.filterable ? this.validateOption(cOptions) : option
              if (!optionPassesFilter) continue
            }
            optionCounter = optionCounter + 1
            selectOptions.push(this.processOption(option, selectedValues, optionCounter === currentIndex))
          }
        }
        return selectOptions
      },
      flatOptions() {
        return extractOptions(this.selectOptions)
      },
      selectTabindex() {
        return this.disabled || this.filterable ? -1 : 0
      },
      remote() {
        return typeof this.remoteMethod === 'function'
      }
    },
    methods: {
      setQuery(query) { // PUBLIC API
        if (query) {
          this.onQueryChange(query)
          return
        }
        if (query === null) {
          this.onQueryChange('')
          this.values = []
        }
      },
      clearSingleSelect() { // PUBLIC API
        this.$emit('on-clear')
        this.hideMenu()
        if (this.clearable) this.reset()
      },
      getOptionData(value) {
        const option = this.flatOptions.find(({ componentOptions }) => componentOptions.propsData.value === value)
        if (!option) return null
        const label = getOptionLabel(option)
        return {
          value: value,
          label: label
        }
      },
      getInitialValue() {
        const { multiple, remote, value } = this
        let initialValue = Array.isArray(value) ? value : [value]
        if (!multiple && (typeof initialValue[0] === 'undefined' || (String(initialValue[0]).trim() === '' && !Number.isFinite(initialValue[0])))) initialValue = []
        if (remote && !multiple && value) {
          const data = this.getOptionData(value)
          this.query = data ? data.label : String(value)
        }
        return initialValue.filter((item) => {
          return Boolean(item) || item === 0
        })
      },
      processOption(option, values, isFocused) {
        if (!option.componentOptions) return option
        const optionValue = option.componentOptions.propsData.value
        const disabled = option.componentOptions.propsData.disabled
        const isSelected = values.includes(optionValue)

        const propsData = {
          ...option.componentOptions.propsData,
          selected: isSelected,
          isFocused: isFocused,
          disabled: typeof disabled === 'undefined' ? false : disabled !== false
        }
        return {
          ...option,
          componentOptions: {
            ...option.componentOptions,
            propsData: propsData
          }
        }
      },
      validateOption({ children, elm, propsData }) {
        const value = propsData.value
        const label = propsData.label || ''
        const textContent = (elm && elm.textContent) || (children || []).reduce((str, node) => {
          const nodeText = node.elm ? node.elm.textContent : node.text
          return `${str} ${nodeText}`
        }, '') || ''
        const stringValues = JSON.stringify([value, label, textContent])
        const query = this.query.toLowerCase().trim()
        return stringValues.toLowerCase().includes(query)
      },
      toggleMenu(e, force) {
        if (this.disabled) {
          return false
        }

        this.visible = typeof force !== 'undefined' ? force : !this.visible
        if (this.visible) {
          this.dropDownWidth = this.$el.getBoundingClientRect().width
          this.broadcast('Drop', 'on-update-popper')
        }
      },
      hideMenu() {
        this.toggleMenu(null, false)
        setTimeout(() => {
          this.unchangedQuery = true
        }, ANIMATION_TIMEOUT)
      },
      onClickOutside() {
        if (this.visible) {
          if (this.appendToBody) {
            const { $el } = this.$refs.dropdown
            if ($el === event.target || $el.contains(event.target)) {
              return
            }
          }
          if (this.filterable) {
            const input = this.$el.querySelector('input[type="text"]')
            this.caretPosition = input.selectionStart
            this.$nextTick(() => {
              const caretPosition = this.caretPosition === -1 ? input.value.length : this.caretPosition
              input.setSelectionRange(caretPosition, caretPosition)
            })
          }
          this.hideMenu()
          this.isFocused = true
        } else {
          this.caretPosition = -1
          this.isFocused = false
        }
      },
      reset() {
        this.query = ''
        this.focusIndex = -1
        this.unchangedQuery = true
        this.values = []
        this.filterQueryChange = false
      },
      onOptionClick(option) {
        if (this.multiple) {
          // keep the query for remote select
          if (this.remote) {
            this.lastRemoteQuery = this.lastRemoteQuery || this.query
          } else {
            this.lastRemoteQuery = ''
          }

          const valueIsSelected = this.values.find(({ value }) => value === option.value)
          if (valueIsSelected) {
            this.values = this.values.filter(({ value }) => value !== option.value)
          } else {
            this.values = this.values.concat(option)
          }

          this.isFocused = true // so we put back focus after clicking with mouse on option elements
        } else {
          this.query = String(option.label).trim()
          this.values = [option]
          this.lastRemoteQuery = ''
          this.hideMenu()
        }
        this.focusIndex = this.flatOptions.findIndex((opt) => {
          if (!opt || !opt.componentOptions) return false
          return opt.componentOptions.propsData.value === option.value
        })
        if (this.filterable) {
          const inputField = this.$el.querySelector('input[type="text"]')
          if (!this.autoComplete) this.$nextTick(() => inputField.focus())
        }
        this.broadcast('Drop', 'on-update-popper')
        setTimeout(() => {
          this.filterQueryChange = false
        }, ANIMATION_TIMEOUT)
      },
      onQueryChange(query) {
        if (query.length > 0 && query !== this.query) {
          // in 'AutoComplete', when set an initial value asynchronously,
          // the 'dropdown list' should be stay hidden.
          // [issue #5150]
          if (this.autoComplete) {
            this.visible = document.hasFocus &&
              document.hasFocus() &&
              document.activeElement === this.$el.querySelector('input')
          } else {
            this.visible = true
          }
        }

        this.query = query
        this.unchangedQuery = this.visible
        this.filterQueryChange = true
      },
      toggleHeaderFocus({ type }) {
        if (this.disabled) {
          return
        }
        this.isFocused = type === 'focus'
      },
      updateSlotOptions() {
        this.slotOptions = this.$slots.default
      },
      checkUpdateStatus() {
        if (this.getInitialValue().length > 0 && this.selectOptions.length === 0) {
          this.hasExpectedValue = true
        }
      }
    },
    watch: {
      value(value) {
        const { getInitialValue, getOptionData, publicValue, values } = this

        this.checkUpdateStatus()

        if (value === '') {
          this.values = []
        } else if (checkValuesNotEqual(value, publicValue, values)) {
          this.$nextTick(() => {
            this.values = getInitialValue().map(getOptionData).filter(Boolean)
          })
          this.dispatch('BFormItem', 'on-form-change', this.publicValue)
        }
      },
      values(now, before) {
        const newValue = JSON.stringify(now)
        const oldValue = JSON.stringify(before)
        // v-model is always just the value, event with labelInValue === true
        const vModelValue = (this.publicValue && this.labelInValue)
          ? (this.multiple ? this.publicValue.map(({ value }) => value) : this.publicValue.value)
          : this.publicValue
        const shouldEmitInput = newValue !== oldValue && vModelValue !== this.value
        if (shouldEmitInput) {
          this.$emit('input', vModelValue) // to update v-model
          this.$emit('on-change', this.publicValue)
          this.dispatch('BFormItem', 'on-form-change', this.publicValue)
        }
      },
      query(query) {
        this.$emit('on-query-change', query)
        const { remoteMethod, lastRemoteQuery } = this
        const hasValidQuery = query !== '' && (query !== lastRemoteQuery || !lastRemoteQuery)
        const shouldCallRemoteMethod = remoteMethod && hasValidQuery && !this.preventRemoteCall
        this.preventRemoteCall = false // remove the flag

        if (shouldCallRemoteMethod) {
          this.focusIndex = -1
          const promise = this.remoteMethod(query)
          this.initialLabel = ''
          if (promise && promise.then) {
            promise.then(options => {
              if (options) this.options = options
            })
          }
        }
        if (query !== '' && this.remote) this.lastRemoteQuery = query
      },
      loading(state) {
        if (state === false) {
          this.updateSlotOptions()
        }
      },
      isFocused(focused) {
        const el = this.filterable ? this.$el.querySelector('input[type="text"]') : this.$el
        el[this.isFocused ? 'focus' : 'blur']()

        // restore query value in filterable single selects
        const [selectedOption] = this.values
        if (selectedOption && this.filterable && !this.multiple && !focused) {
          const selectedLabel = String(selectedOption.label || selectedOption.value).trim()
          if (selectedLabel && this.query !== selectedLabel) {
            this.preventRemoteCall = true
            this.query = selectedLabel
          }
        }
      },
      focusIndex(index) {
        if (index < 0 || this.autoComplete) return
        // update scroll
        const optionValue = this.flatOptions[index].componentOptions.propsData.value
        const optionInstance = findChild(this, ({ $options }) => {
          return $options.componentName === 'select-item' && $options.propsData.value === optionValue
        })

        let bottomOverflowDistance = optionInstance.$el.getBoundingClientRect().bottom - this.$refs.dropdown.$el.getBoundingClientRect().bottom
        let topOverflowDistance = optionInstance.$el.getBoundingClientRect().top - this.$refs.dropdown.$el.getBoundingClientRect().top
        if (bottomOverflowDistance > 0) {
          this.$refs.dropdown.$el.scrollTop += bottomOverflowDistance
        }
        if (topOverflowDistance < 0) {
          this.$refs.dropdown.$el.scrollTop += topOverflowDistance
        }
      },
      dropVisible(open) {
        this.broadcast('Drop', open ? 'on-update-popper' : 'on-destroy-popper')
      },
      selectOptions() {
        if (this.hasExpectedValue && this.selectOptions.length > 0) {
          if (this.values.length === 0) {
            this.values = this.getInitialValue()
          }
          this.values = this.values.map(this.getOptionData).filter(Boolean)
          this.hasExpectedValue = false
        }

        if (this.slotOptions && this.slotOptions.length === 0) {
          this.query = ''
        }

        // 当 dropdown 一开始在控件下部显示，而滚动页面后变成在上部显示，如果选项列表的长度由内部动态变更了(搜索情况)
        // dropdown 的位置不会重新计算，需要重新计算
        this.broadcast('Drop', 'on-update-popper')
      },
      visible(state) {
        this.$emit('on-open-change', state)
      },
      slotOptions(options, old) {
        // #4626，当 Options 的 label 更新时，v-model 的值未更新
        // remote 下，调用 getInitialValue 有 bug
        if (!this.remote) {
          const values = this.getInitialValue()
          if (this.flatOptions && this.flatOptions.length && values.length && !this.multiple) {
            this.values = values.map(this.getOptionData).filter(Boolean)
          }
        }

        // 当 dropdown 在控件上部显示时，如果选项列表的长度由外部动态变更了，
        // dropdown 的位置会有点问题，需要重新计算
        if (options && old && options.length !== old.length) {
          this.broadcast('Drop', 'on-update-popper')
        }
      }
    }
  }
</script>
